iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Software Development

30 天的 Functional Programming 之旅系列 第 21

[Day 21] Monad 入門 (1):撫平巢狀的洋蔥

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251005/20168201KqLRVJDJBh.png

前言

在過去幾篇文章中,我們認識了 Functor 這個 FP 工具,透過 .map,我們學會了如何在一個「容器」或「上下文 (context)」內,對值進行操作,而完全不用擔心容器本身的結構。無論是可能為空的 Maybe、帶有錯誤分支的 Either,還是封裝著副作用的 IOTask,Functor 都讓我們能以一種優雅且可組合的方式來建立資料處理管道。

這一切看起來非常美好,我們的函數組合 (composepipe) 如絲般順滑。只要我們的函數是單純地從 a 一般值轉換到 b 一般值(a -> b),Functor 的世界就完美無瑕。

但現在有個問題:「如果我們想要 map 的那個函式,它本身的回傳值也是一個容器呢?例如 a -> M(b) 這樣?」

當我們將 M(b) 組合到下一個資料處理管道時,就像是為一顆洋蔥包上了另一層皮。我們得到的不是 Maybe(user),而是 Maybe(Maybe(user));不是 IO(data),而是 IO(IO(data))

https://ithelp.ithome.com.tw/upload/images/20251005/201682012fd02WtUuT.png
圖 1 Functor 的 .map 處理回傳容器的函數時,會產生巢狀結構(資料來源: 自行繪製)

這就是「巢狀洋蔥」問題,而今天要介紹的 Monad 就是為了解決這問題,以便我們繼續流暢的組合,接著就來看看吧~

前情提要:Pointed Functor 與 of 的意義

在探討 Monad 如何解決巢狀問題之前,先回顧一個我們已經熟悉,但可能還未完全理解其重要性的方法:.of

一開始我們可能認為 Maybe.of(x) 只是 new Maybe(x) 的一種語法糖,或是一種避免使用 new 關鍵字的 FP 風格。但它的意義不止於此。

一個實作了 of 方法的 Functor,我們稱之為 Pointed Functor。

.of 的真正目的,是提供一個標準化的介面,將任何一個「一般值世界」的值,放入該 Functor 的「預設最小脈絡 (default minimal context)」中。它回答了這個問題:「如果要把一個一般值放進這個容器裡,最安全、最通用的方式是什麼?」

每個 Functor 只能有一種放入值的方式,以 Either 為例,Either 有 LeftRight 兩種狀態,但只有 Right 是可以被 .map 的。因此,Either 的「預設最小脈絡」就是 Right。這就是為什麼 Either.of(5) 的結果會是 Right(5),而不是 Left(5)Left.of 在概念上是沒有意義的,因為 Left 代表計算的中斷,而不是一個可以繼續操作的容器。

在不同的函式庫或文獻中,of 也被稱為 pureunitreturn。它們本質上都在描述相同的功能:將一個一般值「提升」到容器的脈絡中,換句話說,of 會將一個值從「一般值的世界」提升到「容器包裹值」的世界。
https://ithelp.ithome.com.tw/upload/images/20251005/20168201phpw8NrliY.png
圖 2 of 會將一個值從「一般值的世界」提升到「容器包裹值」的世界(資料來源: 自行繪製)

理解了 Pointed Functor,就比較能理解 Monad 的定義,稍後會看到,Monad 的定義就是:「一個可以被壓平的 Pointed Functor」。

為什麼我們需要 Monad?當函數回傳一個容器

先從一個熟悉的 Maybe Functor 開始,看看它在巢狀組合中的問題。

情境:安全地取得街道資料

假設我們要處理一個使用者物件,目標是取得該使用者地址中的街道內容。這過程有三個步驟,且每一步都可能失敗:

  1. 從使用者物件中取得地址陣列,使用者可能沒有登記地址
  2. 從地址陣列中取得第一個值,地址陣列可能沒有第一個值
  3. 從地址陣列取得街道物件,街道物件可能不存在

簡單來說這是一個 user.addresses[0].street 的巢狀取值,每一層取值都可能遇到值不存在的狀況,為了處理這種「可能不存在」的情況,我們可使用 Maybe,並定義兩個「安全」的函式,它們會將可能為 nullundefined 的結果包裝進 Maybe 容器中:

// --- 小工具 -----------------------------------------------------------
// compose :: ((b -> c), (a -> b)...) -> a -> c
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

// curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c
const curry = (fn) => {
  const arity = fn.length;
  const $curry = (...args) => {
    if (args.length < arity) {
      return $curry.bind(null, ...args);
    }
    return fn.call(null, ...args);
  };
  return $curry;
};

// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((fn, f) => f.map(fn));

// ---  Maybe -------------------------------------------------------
const Maybe = {
  of: (value) =>
    value === null || value === undefined ? new Nothing() : new Just(value)
};

class Just {
  constructor(value) {
    this.$value = value;
  }
  map(fn) {
    return Maybe.of(fn(this.$value));
  }
  getOrElse(defaultValue) {
    return this.$value;
  }
  toString() {
    return `Just(${this.$value})`;
  }
}

class Nothing {
  map(fn) { return this; }
  getOrElse(defaultValue) { return defaultValue; }
  toString() { return 'Nothing()'; }
}

// --- 安全取值的函式 -------------------------------------------
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((key, obj) => Maybe.of(obj?.[key]));

// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);

現在我們試著用 .map 把這三步串連起來:

// --- 取第一個 address 的 street ------------------------------
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
  map(map(safeProp('street'))), // [3] Maybe(Maybe(Address)) -> Maybe(Maybe(Maybe(Street)))
  map(safeHead),                // [2] Maybe([Address]) -> Maybe(Maybe(Address))
  safeProp('addresses')         // [1] User -> Maybe([Address])
);

然後傳入我們的 user 資料:

const userNoAddresses = { name: 'Amy' };
const userWithStreet = {
  name: 'John',
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
};

const nothingResult = firstAddressStreet(userNoAddresses); 
const nestedResult = firstAddressStreet(userWithStreet);

console.log(nothingResult); // Nothing {}
console.log(nestedResult); // Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

可以看到當我們傳入 userWithStreet 時,得到的不是 Maybe(address),而是 Maybe(Maybe(Maybe(address)))。這就是我們所說的「巢狀的洋蔥」或「盒子裡的盒子」。

完整程式可參考此連結

巢狀結構的問題在哪?

它破壞了我們一直以來努力維護的組合性 (Composition)。

1. 組合鏈被打斷

我們無法繼續用 .map 來串接下一個操作。如果我們寫 nestedResult.map(getStreetNumber)getStreetNumber 這個函式會被應用在 Maybe(Maybe(address)) 上,而不是它期望的 address 物件上,這會導致非預期的結果或錯誤。我們的函數處理管道在此卡住了。

2. 使用時變得麻煩

為了從 Maybe(Maybe(Maybe(address))) 中取出最終的街道資料,我們必須手動地、命令式地「拆箱」:先檢查外層的 Maybe 是 Just 還是 Nothing,如果是 Just,再取出裡面的 Maybe,再對它進行一次檢查... 這違背了我們使用 Maybe 來避免 if/else 巢狀地獄的初衷。

Functor 的 .map 是我們在 context 中(或說是容器中)進行組合的關鍵。它讓我們可以順暢地建立 pipe(f, g, h) 這樣的處理流程。然而,當流程中的某個函式(如 getAddress)本身就會創造一個新的 context 或容器時,.map 只是忠實地將這個新context 包進來,導致了 M(M(b)) 這種「阻塞物」。

我們需要一個能夠理解並處理這種「函數回傳容器」情況的工具,進而修復我們斷掉的組合鏈。

巢狀的解法:join

為了解決這個「巢狀容器」問題,Monad 引入了一個函數:join
join 的功能非常單純:將任何兩層相同型別的容器壓平 (flatten) 成一層,以下是 join 的型別簽章。

join :: Monad m => m (m a) -> m a

https://ithelp.ithome.com.tw/upload/images/20251005/20168201psKtqFUVF7.png
圖 3 join 能將兩層相同型別的容器壓平為一層(資料來源: 自行繪製)

它的作用就像是從一個箱子裡,把內部的那個箱子拿出來,丟掉外層的箱子。讓我們看看 join 如何改善我們的程式碼:

const join = m => m.join();

const firstAddressStreet =
  compose(
    join,                    // Maybe(street)
    map(safeProp('street')), // Maybe({...}) -> Maybe(Maybe(street))
    join,                    // Maybe(head)
    map(safeHead),           // Maybe([...]) -> Maybe(Maybe(head))
    safeProp('addresses')    // obj -> Maybe(addresses)
  );

const result = firstAddressStreet(userWithStreet); // Maybe({name: 'Mulburry', number: 8402})

在每個產生新 Maybe.map 操作後加上 .join(),我們成功地將結構的深度控制在了一層。程式碼不再是可怕的巢狀 map,而是線性的鏈式呼叫。

而 Maybe 的 join 方法可以這樣定義,以下將現有的 Maybe 加上 join 方法:

// 這裡用 instanceof 判斷是否為 Maybe,但實務上可改用 _tag 來辨別型別
const isMaybe = (x) => x instanceof Just || x instanceof Nothing;

const Maybe = {
  of: (value) =>
    value === null || value === undefined ? new Nothing() : new Just(value)
};

class Just {
  constructor(value) {
    this.$value = value;
  }
  map(fn) {
    return Maybe.of(fn(this.$value));
  }
  getOrElse(defaultValue) {
    return this.$value;
  }
  toString() {
    return `Just(${this.$value})`;
  }
  // 新增 join 方法
  join() {
    return isMaybe(this.$value) ? this.$value : this;
  }
}

class Nothing {
  map(fn) { return this; }
  getOrElse(defaultValue) { return defaultValue; }
  toString() { return 'Nothing()'; } 
  // 新增 join 方法
  join() { return this; }
}

完整程式碼可參考此連結

這種可以被「壓平」的能力,正是 Monad 之所以為 Monad 的關鍵特徵之一。現在,前言提到的定義就說得通了:

一個 Monad,就是一個可以被壓平的 Pointed Functor。(A Monad is a pointed functor that can flatten.)

另一個更常見的定義敘述是:

一個型別若同時提供 ofchain,並且滿足 Monad 的三條定律(結合律、左右單位律),那它就是 Monad。

再優化一點:chain

雖然 join 解決了巢狀問題,但可能有人會注意到,map(f).join() 這種模式在程式碼中不斷重複出現,顯得有些累贅。既然這個模式如此常用,我們何不把它們打包成一個新的方法呢?

這就是 chain誕生的原因。chain = map + join

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());

// 或者

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));

chain 的其他稱呼例如:

  • >>=(稱為 bind
  • flatMap
  • JavaScript 社群中慣稱為 chain

chainmapjoin 這兩個步驟合併為一個操作。

https://ithelp.ithome.com.tw/upload/images/20251005/20168201X65JG1wqmh.png
圖 4 chain(f) 等價於 map(f) 加上 join(資料來源: 自行繪製)

現在我們用 chain 來重寫 firstAddressStreet

const firstAddressStreet = compose(
  chain(safeProp('street')), // head -> Maybe(street)
  chain(safeHead),           // addresses -> Maybe(head)
  safeProp('addresses')      // obj -> Maybe(addresses)
);

完整程式請見此連結

chain 隱藏了 mapjoin 的細節,讓我們可以專注於組合我們的業務邏輯,而不用擔心容器的巢狀問題。

補充:Infix vs. Prefix

m.chain(f) 這種呼叫形式被稱為 infix (中綴) 或方法形式,因為 chain 寫在物件和函式之間。而在許多函式庫(如 Ramda)中,可能也會看到 prefix (前綴) 或函式形式,也就是將 chain 寫在最前面,作為一般函數來呼叫:

// Infix (方法形式)
Maybe.of(3).chain(x => Maybe.of(x + 1));

// Prefix (函式形式),資料(functor)置後
// chain(f, m)
chain(x => Maybe.of(x + 1), Maybe.of(3));

兩者在概念上是等價的,只是呼叫風格不同。

小結

以下幾點回顧今天文章重點。

map 遇到的問題:巢狀容器

當我們用 Functor 的 .map 處理一個會回傳容器(例如 a -> M(b))的函數時,會產生「盒子裡的盒子」的巢狀結構(M(M(b))),這會破壞函數組合的流暢性。

解法

我們可用兩種工具來解決這個問題:

  • .join():一個簡單的「壓平」操作,能將兩層相同的容器扁平化為一層
  • .chain():一個更方便的工具,它將 mapjoin 這兩個步驟合而為一。.chain(f) 相當於 .map(f).join()

Monad 的定義

Monad 可以理解為「一個可以被壓平的 Pointed Functor」,也等價於「實作了 ofchain 並遵守某些定律的型別」。Monad 讓我們可以將多個帶有 context 的計算串接成一個扁平、線性的處理流程,避免了手動拆箱和繁瑣的巢狀程式碼。


我們已經看到了 chain 如何解決巢狀問題,但要一個型別真正成為 Monad,它還需要滿足一些定律。這些定律確保了 chain 的行為是可預測的。在下一篇文章中,我們會再更瞭解 Monad 到底是什麼,以及它要遵循哪些定律。

Reference


上一篇
[Day 20] Task:處理非同步副作用
下一篇
[Day 22] Monad 入門 (2):核心概念與定律
系列文
30 天的 Functional Programming 之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言